python-substack 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_substack-0.8.0.dist-info → python_substack-0.9.0.dist-info}/METADATA +16 -2
- python_substack-0.9.0.dist-info/RECORD +14 -0
- substack/__init__.py +1 -1
- substack/api.py +41 -1
- substack/cli.py +4 -0
- substack/mdexport.py +16 -1
- substack/mdrender.py +65 -16
- python_substack-0.8.0.dist-info/RECORD +0 -14
- {python_substack-0.8.0.dist-info → python_substack-0.9.0.dist-info}/WHEEL +0 -0
- {python_substack-0.8.0.dist-info → python_substack-0.9.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.8.0.dist-info → python_substack-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -94,6 +94,7 @@ Substack result:
|
|
|
94
94
|
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
95
95
|
- List and inspect publications and drafts.
|
|
96
96
|
- Export drafts to loss-aware Markdown backups without server writes.
|
|
97
|
+
- Safely update drafts from Markdown, preserving unsupported editor widgets by default.
|
|
97
98
|
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
98
99
|
- Use stable JSON envelopes in scripts and automation.
|
|
99
100
|
- Authenticate with browser cookies or email and password.
|
|
@@ -145,6 +146,7 @@ substack publications list
|
|
|
145
146
|
substack drafts list --limit 10
|
|
146
147
|
substack drafts get 12345
|
|
147
148
|
substack drafts export 12345 --output backup.md
|
|
149
|
+
substack drafts update 12345 revised-post.md --dry-run --yes
|
|
148
150
|
substack --publication-url https://example.substack.com drafts list
|
|
149
151
|
```
|
|
150
152
|
|
|
@@ -217,6 +219,17 @@ print(backup["markdown"])
|
|
|
217
219
|
print(backup["unsupported_nodes"])
|
|
218
220
|
```
|
|
219
221
|
|
|
222
|
+
Review a Markdown update before writing it, then update deliberately:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
|
|
226
|
+
result = api.update_draft_from_markdown(12345, "# Revised draft")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Unsupported editor widgets exported as `python-substack-node:v1` markers are
|
|
230
|
+
preserved by default. An update refuses to remove or alter them unless
|
|
231
|
+
`allow_unsupported_change=True` is explicitly passed.
|
|
232
|
+
|
|
220
233
|
For direct ProseMirror node construction, see the
|
|
221
234
|
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
222
235
|
[YAML drafts](docs/yaml.md).
|
|
@@ -269,11 +282,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
|
|
|
269
282
|
- [Python SDK](docs/python-sdk.md)
|
|
270
283
|
- [Authentication](docs/authentication.md)
|
|
271
284
|
- [Markdown reference](docs/markdown.md)
|
|
285
|
+
- [Draft backup and safe updates](docs/cli.md)
|
|
272
286
|
- [Legacy CLI commands](docs/legacy-cli.md)
|
|
273
287
|
- [Low-level Python API](docs/low-level-api.md)
|
|
274
288
|
- [YAML drafts](docs/yaml.md)
|
|
275
289
|
- [MCP server](docs/mcp.md)
|
|
276
|
-
- [
|
|
290
|
+
- [Agent workflow](docs/agents.md)
|
|
277
291
|
- [Safety and publishing behavior](docs/safety.md)
|
|
278
292
|
- [Troubleshooting](docs/troubleshooting.md)
|
|
279
293
|
- [Compatibility policy](docs/compatibility.md)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
substack/__init__.py,sha256=W0VG-klGuBHRwwQxQWEdvxB68z6YVQMvEAWO3yNn8bY,449
|
|
2
|
+
substack/api.py,sha256=k6ympBEJnwE3Gy0n2qGYTM4d26As1--SZTgZQWl7PzU,32176
|
|
3
|
+
substack/cli.py,sha256=56BTWxfr3gpAaJOLJsKRG7k6PPT7xSgLdp632H4mezA,26292
|
|
4
|
+
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
+
substack/mdexport.py,sha256=lg2DCTHWRa_w7O2eO6ks1Iif2A7_tphDIayI0w_yxKk,14037
|
|
6
|
+
substack/mdrender.py,sha256=Bq0VXs0B3cC2fpKzyRNxApM-ZbK_-pC9wPvn1HIanHw,13666
|
|
7
|
+
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
8
|
+
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
9
|
+
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
10
|
+
python_substack-0.9.0.dist-info/METADATA,sha256=1S7eMKYmbI8AMKG5rruRyRcUWESxELT6XStsQQeRCsY,9299
|
|
11
|
+
python_substack-0.9.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
+
python_substack-0.9.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
+
python_substack-0.9.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
+
python_substack-0.9.0.dist-info/RECORD,,
|
substack/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.
|
|
6
|
+
__version__ = "0.9.0"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
9
|
__description__ = (
|
substack/api.py
CHANGED
|
@@ -21,6 +21,19 @@ logger = logging.getLogger(__name__)
|
|
|
21
21
|
__all__ = ["Api"]
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _image_attrs(document):
|
|
25
|
+
images = []
|
|
26
|
+
if isinstance(document, dict):
|
|
27
|
+
if document.get("type") == "image2" and isinstance(document.get("attrs"), dict):
|
|
28
|
+
images.append(document["attrs"])
|
|
29
|
+
for value in document.values():
|
|
30
|
+
images.extend(_image_attrs(value))
|
|
31
|
+
elif isinstance(document, list):
|
|
32
|
+
for value in document:
|
|
33
|
+
images.extend(_image_attrs(value))
|
|
34
|
+
return images
|
|
35
|
+
|
|
36
|
+
|
|
24
37
|
class Api:
|
|
25
38
|
"""
|
|
26
39
|
|
|
@@ -432,7 +445,10 @@ class Api:
|
|
|
432
445
|
f"{self.publication_url}/drafts",
|
|
433
446
|
params={"filter": filter, "offset": offset, "limit": limit},
|
|
434
447
|
)
|
|
435
|
-
|
|
448
|
+
drafts = Api._handle_response(response=response)
|
|
449
|
+
if isinstance(drafts, dict) and isinstance(drafts.get("posts"), list):
|
|
450
|
+
return drafts["posts"]
|
|
451
|
+
return drafts
|
|
436
452
|
|
|
437
453
|
def get_draft(self, draft_id):
|
|
438
454
|
"""
|
|
@@ -479,6 +495,7 @@ class Api:
|
|
|
479
495
|
tags=None,
|
|
480
496
|
dry_run: bool = False,
|
|
481
497
|
allow_unsupported_change: bool = False,
|
|
498
|
+
allow_image_replacement: bool = False,
|
|
482
499
|
) -> dict:
|
|
483
500
|
"""
|
|
484
501
|
Update an existing draft body from Markdown, with optional metadata changes.
|
|
@@ -511,6 +528,29 @@ class Api:
|
|
|
511
528
|
)
|
|
512
529
|
post.from_markdown(markdown, api=self)
|
|
513
530
|
|
|
531
|
+
remote_images = _image_attrs(draft_body)
|
|
532
|
+
submitted_images = _image_attrs(post.draft_body)
|
|
533
|
+
for submitted in submitted_images:
|
|
534
|
+
matches = [
|
|
535
|
+
image
|
|
536
|
+
for image in remote_images
|
|
537
|
+
if image.get("src") == submitted.get("src")
|
|
538
|
+
]
|
|
539
|
+
if len(matches) == 1:
|
|
540
|
+
source, alt, href = (
|
|
541
|
+
submitted.get("src"),
|
|
542
|
+
submitted.get("alt"),
|
|
543
|
+
submitted.get("href"),
|
|
544
|
+
)
|
|
545
|
+
submitted.update(matches[0])
|
|
546
|
+
submitted.update(
|
|
547
|
+
{"src": source, "alt": alt, "href": href, "isProcessing": False}
|
|
548
|
+
)
|
|
549
|
+
elif not allow_image_replacement:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
"Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
|
|
552
|
+
)
|
|
553
|
+
|
|
514
554
|
_, submitted_unsupported = document_to_markdown(post.draft_body)
|
|
515
555
|
|
|
516
556
|
if not allow_unsupported_change:
|
substack/cli.py
CHANGED
|
@@ -312,6 +312,8 @@ def _drafts_create(api, args):
|
|
|
312
312
|
def _drafts_update(api, args):
|
|
313
313
|
if args.allow_unsupported_change and not args.yes:
|
|
314
314
|
raise CLIUsageError("--yes is required when using --allow-unsupported-change")
|
|
315
|
+
if args.allow_image_replacement and not args.yes:
|
|
316
|
+
raise CLIUsageError("--yes is required when using --allow-image-replacement")
|
|
315
317
|
|
|
316
318
|
if not args.yes and (args.json_output or not sys.stdin.isatty()):
|
|
317
319
|
raise CLIUsageError("--yes is required in non-interactive or JSON mode")
|
|
@@ -351,6 +353,7 @@ def _drafts_update(api, args):
|
|
|
351
353
|
tags=args.tags,
|
|
352
354
|
dry_run=args.dry_run,
|
|
353
355
|
allow_unsupported_change=args.allow_unsupported_change,
|
|
356
|
+
allow_image_replacement=args.allow_image_replacement,
|
|
354
357
|
)
|
|
355
358
|
|
|
356
359
|
if args.json_output:
|
|
@@ -513,6 +516,7 @@ def _build_parser():
|
|
|
513
516
|
drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
514
517
|
drafts_update.add_argument("--dry-run", action="store_true")
|
|
515
518
|
drafts_update.add_argument("--allow-unsupported-change", action="store_true")
|
|
519
|
+
drafts_update.add_argument("--allow-image-replacement", action="store_true")
|
|
516
520
|
drafts_update.add_argument("--yes", action="store_true")
|
|
517
521
|
drafts_update.set_defaults(handler=_drafts_update)
|
|
518
522
|
|
substack/mdexport.py
CHANGED
|
@@ -57,6 +57,21 @@ def _marker(node, unsupported):
|
|
|
57
57
|
return f"<!-- python-substack-node:v1 {encoded} -->"
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _image_marker(attrs):
|
|
61
|
+
preserved = {
|
|
62
|
+
key: value
|
|
63
|
+
for key, value in attrs.items()
|
|
64
|
+
if key not in {"src", "alt", "href", "isProcessing"}
|
|
65
|
+
}
|
|
66
|
+
if not preserved:
|
|
67
|
+
return ""
|
|
68
|
+
payload = json.dumps(
|
|
69
|
+
preserved, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
70
|
+
).encode("utf-8")
|
|
71
|
+
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
72
|
+
return f"\n<!-- python-substack-image:v1 {encoded} -->"
|
|
73
|
+
|
|
74
|
+
|
|
60
75
|
def _require_dict(node, context):
|
|
61
76
|
if not isinstance(node, dict):
|
|
62
77
|
raise ValueError(f"Malformed draft body: {context} must be an object")
|
|
@@ -247,7 +262,7 @@ def _render_image(node, unsupported):
|
|
|
247
262
|
if not isinstance(href, str):
|
|
248
263
|
return _marker(node, unsupported)
|
|
249
264
|
image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
|
|
250
|
-
return image_markdown
|
|
265
|
+
return image_markdown + _image_marker(attrs)
|
|
251
266
|
|
|
252
267
|
|
|
253
268
|
def _render_footnote(node, unsupported):
|
substack/mdrender.py
CHANGED
|
@@ -21,7 +21,9 @@ import base64
|
|
|
21
21
|
import copy
|
|
22
22
|
import json
|
|
23
23
|
import re
|
|
24
|
+
from pathlib import Path
|
|
24
25
|
from typing import Dict, List, Optional
|
|
26
|
+
from urllib.parse import unquote, urlsplit
|
|
25
27
|
|
|
26
28
|
from markdown_it import MarkdownIt
|
|
27
29
|
from markdown_it.tree import SyntaxTreeNode
|
|
@@ -43,22 +45,24 @@ _MARK_FOR = {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
|
|
46
|
-
def parse_node_marker(comment_content: str) -> dict | None:
|
|
48
|
+
def parse_node_marker(comment_content: str) -> dict | None:
|
|
47
49
|
"""
|
|
48
50
|
Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
|
|
49
|
-
|
|
51
|
+
|
|
50
52
|
If it is not a python-substack-node:v1 marker, returns None.
|
|
51
53
|
If it is an attempted marker but is corrupt/malformed, raises ValueError.
|
|
52
54
|
"""
|
|
53
55
|
clean = comment_content.strip()
|
|
54
|
-
match = re.match(
|
|
56
|
+
match = re.match(
|
|
57
|
+
r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
|
|
58
|
+
)
|
|
55
59
|
if not match:
|
|
56
60
|
if "python-substack-node:v1" in clean:
|
|
57
61
|
raise ValueError("Corrupt marker format")
|
|
58
62
|
return None
|
|
59
|
-
|
|
63
|
+
|
|
60
64
|
encoded = match.group(1)
|
|
61
|
-
|
|
65
|
+
|
|
62
66
|
try:
|
|
63
67
|
padding = len(encoded) % 4
|
|
64
68
|
if padding:
|
|
@@ -84,7 +88,29 @@ def parse_node_marker(comment_content: str) -> dict | None:
|
|
|
84
88
|
if not isinstance(node_type, str) or not node_type:
|
|
85
89
|
raise ValueError("Marker payload has missing or empty 'type'")
|
|
86
90
|
|
|
87
|
-
return data
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_image_marker(comment_content: str) -> dict | None:
|
|
95
|
+
clean = comment_content.strip()
|
|
96
|
+
match = re.match(
|
|
97
|
+
r"^<!--\s*python-substack-image:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
|
|
98
|
+
)
|
|
99
|
+
if not match:
|
|
100
|
+
if "python-substack-image:v1" in clean:
|
|
101
|
+
raise ValueError("Corrupt image marker format")
|
|
102
|
+
return None
|
|
103
|
+
encoded = match.group(1)
|
|
104
|
+
encoded += "=" * (-len(encoded) % 4)
|
|
105
|
+
try:
|
|
106
|
+
attrs = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii")))
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise ValueError("Invalid image marker") from exc
|
|
109
|
+
if not isinstance(attrs, dict) or any(
|
|
110
|
+
key in {"src", "alt", "href", "isProcessing"} for key in attrs
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("Invalid image marker attributes")
|
|
113
|
+
return attrs
|
|
88
114
|
|
|
89
115
|
|
|
90
116
|
def _make_parser() -> MarkdownIt:
|
|
@@ -173,13 +199,28 @@ def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
|
|
|
173
199
|
|
|
174
200
|
def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
|
|
175
201
|
src = img.attrs.get("src", "")
|
|
176
|
-
if
|
|
177
|
-
src
|
|
178
|
-
|
|
202
|
+
if api is not None and (
|
|
203
|
+
urlsplit(src).scheme.lower() not in ("http", "https")
|
|
204
|
+
and not src.startswith("//")
|
|
205
|
+
):
|
|
206
|
+
# Markdown destinations encode spaces and non-ASCII characters as URLs.
|
|
207
|
+
path = Path(unquote(src)).expanduser()
|
|
208
|
+
if not path.is_file() and src.startswith("/"):
|
|
209
|
+
# Preserve legacy root-relative asset paths only when they resolve
|
|
210
|
+
# to a real file; an existing absolute path always takes precedence.
|
|
211
|
+
relative = Path(unquote(src[1:]))
|
|
212
|
+
if relative.is_file():
|
|
213
|
+
path = relative
|
|
214
|
+
if not path.is_file():
|
|
215
|
+
raise FileNotFoundError(f"Local image file not found: {path}")
|
|
179
216
|
try:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
217
|
+
uploaded = api.get_image(str(path))
|
|
218
|
+
url = uploaded.get("url")
|
|
219
|
+
if not isinstance(url, str) or not url.startswith(("https://", "http://")):
|
|
220
|
+
raise ValueError("Upload did not return an HTTP image URL")
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
raise ValueError(f"Failed to upload local image: {path}") from exc
|
|
223
|
+
src = url
|
|
183
224
|
# markdown-it stores the image alt text as the node's content, not in attrs.
|
|
184
225
|
alt = img.content or img.attrs.get("alt") or None
|
|
185
226
|
# Standard markdown image title `` maps to Substack's caption node.
|
|
@@ -292,10 +333,18 @@ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
|
|
|
292
333
|
|
|
293
334
|
ctx: Dict = {"order": []}
|
|
294
335
|
out: List[Dict] = []
|
|
295
|
-
for node in tree.children:
|
|
296
|
-
if node.type == "footnote_block":
|
|
297
|
-
continue
|
|
298
|
-
|
|
336
|
+
for node in tree.children:
|
|
337
|
+
if node.type == "footnote_block":
|
|
338
|
+
continue
|
|
339
|
+
if node.type == "html_block":
|
|
340
|
+
attrs = parse_image_marker(node.content)
|
|
341
|
+
if attrs is not None:
|
|
342
|
+
if not out or out[-1].get("type") != "captionedImage":
|
|
343
|
+
raise ValueError("Image marker must immediately follow an image")
|
|
344
|
+
out[-1]["content"][0]["attrs"].update(attrs)
|
|
345
|
+
out[-1]["content"][0]["attrs"]["isProcessing"] = False
|
|
346
|
+
continue
|
|
347
|
+
out.extend(_render_block(node, api, ctx))
|
|
299
348
|
|
|
300
349
|
# Emit one footnote block per reference, in anchor order, numbered to match.
|
|
301
350
|
for number, footnote_id in enumerate(ctx["order"], start=1):
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=WVrmiMIx9EAaWFyqP7Bd9794hRFTK9-Hh0d3YsyU1Ps,450
|
|
2
|
-
substack/api.py,sha256=-qJiMrgnYTWuBpM6RdGJQLstuNGSPPaiO6VjH6z0cyg,30585
|
|
3
|
-
substack/cli.py,sha256=L0loIUXhL7d9yZRc8N8xJ7xuudLp4D0ToIyrAbFeVK0,26009
|
|
4
|
-
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
-
substack/mdexport.py,sha256=_pSjioPJIA1wuJ6hYpvODZVswPSUROuzEndVoYnv-yU,13541
|
|
6
|
-
substack/mdrender.py,sha256=BsuwNv0UhXLDBrSN5riLqpCjsGz59ezhv8QwyVSgXh0,11470
|
|
7
|
-
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
8
|
-
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
9
|
-
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
10
|
-
python_substack-0.8.0.dist-info/METADATA,sha256=EctUjZG8YTJove4oWmeaAo1c_Z3L9WmLiZAytrQBdD4,8689
|
|
11
|
-
python_substack-0.8.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
-
python_substack-0.8.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
-
python_substack-0.8.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
-
python_substack-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|